Skip to content

feat!: remove the build.config path and gate upgrades on the record - #21

Merged
ausimian merged 2 commits into
release/1.0.0from
issue/13-step3
Aug 22, 2026
Merged

feat!: remove the build.config path and gate upgrades on the record#21
ausimian merged 2 commits into
release/1.0.0from
issue/13-step3

Conversation

@ausimian

Copy link
Copy Markdown
Owner

Refs #13step 3 of three, the last of the keystone. Targets release/1.0.0.

Steps 1 and 2 are merged: Castle materialises the target's configuration in a :peer
running the target's own code, and Forecastle no longer intercepts configuration at all,
so no release it assembles has build.config any more. This is the cleanup that promised,
plus two changes Forecastle's work specified along the way.

Removed: the build.config path

Castle.Commands.generate/1, expand/1, write_sys_config/2, the build.config branch
of materialise/2, and the public Castle.generate/1. Dead with them: the config
provider stub and its tests.

Nothing called it — Forecastle's only remaining reference is a negative assertion,
refute env_sh =~ "Castle.generate", a test that the call is gone.

materialise/2 is now one path. The surviving case is "there is no release here", and it
uses File.ls rather than File.dir? deliberately: the peer's own refusals name a
missing file, which is right for a release that was unpacked and then damaged and wrong
for one never unpacked — and an empty directory belongs with the absent one.

Changed: make_releases no longer depends on the working directory

release_handler resolves relative paths against :code.root_dir(), not the cwd —
consult/2 and do_write_release/3 both go through root_dir_relative_path/1. So the
only cwd-sensitive thing here was ever its own File.exists? guard, and callers no longer
need to change directory to use it. That matters because the working directory is
VM-global: doing this over rpc on a live node meant any application performing relative
file I/O during the call resolved under the release root.

It must stay create_RELEASES/3, never /4 with the root supplied. /3 is
create_RELEASES("", RelDir, RelFile, LibDirs), and check_rel_data/4 branches on an
empty Root to store lib dirs relative — OTP's comment says this is "to make it easy to
create a relocatable RELEASES file". Verified by experiment rather than argument: /3
with an absolute directory wrote lib/kernel-10.5; /4 with the root wrote
/var/folders/…/lib/kernel-10.5. Passing the root would bake absolute lib paths in and
silently make the release non-relocatable, which no end-state test would catch. The code
comment and a unit test both say so.

The directory is an argument rather than self-derived, matching this repo's existing
convention — under mix test, :code.root_dir() is the OTP installation, which has a
releases/RELEASES, so a self-derived directory short-circuits every test to {:ok, []}
and makes the error paths untestable.

Added: Castle.upgradable/0

A system whose releases/RELEASES was missing when it booted runs from a record OTP
synthesised from the boot script — which names no applications. Upgrading from that
state reports success and leaves any application whose version changed, but whose code
the relup does not explicitly load, running its old code.

Confirmed in sasl-4.3: init/1 falls back to a #release{} whose libs is [];
which_releases reports mk_lib_name(Libs) and mk_lib_name([]) -> []. And the reason
it is silent rather than loud: get_new_libs([], _) -> [], and point_of_no_return calls
code:replace_path/2 only over newlibs.

So the discriminator is exact rather than heuristic — a synthesised record reports an
empty application list, and a real one cannot, because kernel and stdlib are always
present.

upgradable/0 refuses in that state and names the remedy. Note what it must say and why:
the file cannot be repaired from a running node, because release_handler reads it once,
in init/1. Creating it now changes nothing about the record this node works from. The
system has to be restarted — and the release creates the file before it starts, so a
restart is all that is needed.

This replaces a shell-side approximation in bin/castle, which tested whether the file
existed. That passes in exactly the case the gate exists to catch: a file that appeared
after the boot which looked for it.

Docs corrected rather than appended to

AGENTS.md: the two-shape dispatch is no longer described as load-bearing (it records
that the second path existed and must not come back); the write_sys_config umask note
went with the code it described; the concurrent-boot sys.config race is deleted,
because nothing of Castle's runs at boot now; the cold-boot limitation is re-scoped from
"unreachable" to "reachable, untested, and Forecastle's to test"; and a stale claim that
the pinned Forecastle "never calls this" is gone — it does.

RELEASE.md: generate/1 removed from the entries that described it, a Removed entry
for the path, a Changed entry for the cwd independence, and an Added entry for the
gate. README left alone; its staleness is tracked under #9.

Verification

mix precommit green — 86 tests. Compiled and tested against Elixir 1.20.0/OTP 28
and 1.18.3/OTP 27 as well: clean, 86 passing on both.

Cross-repo, which is the only thing that exercises the peer path end to end: Forecastle's
suite with its fixture pointed at this branch, --include e2e, 170 tests, 0 failures.
The installed version carries CASTLE_MATERIALISED=true and a sys.config.pristine, with
Mix's own header preserved.

ausimian and others added 2 commits August 22, 2026 20:53
Castle materialises a target's configuration in a peer running the
target's own code, and from Forecastle 1.0.0 no release it assembles has
a build.config, so the branch that read one was unreachable. Deleting it
takes generate/1, the boundary function that called it, and the provider
fold and sys.config writer it needed: the last copy Castle held of a
pipeline Config.Provider already implements.

What materialise/2 still has to answer is a path with no release at it.
The peer's refusals name a file the version is missing, which is the
right answer for a release that was unpacked and then damaged and the
wrong one for a version that was never unpacked - and absent, empty and
not-a-directory are all that second case, so all three say to unpack the
release first.

make_releases/0 no longer resolves the releases directory against the
working directory. release_handler resolves its own relative paths
against code:root_dir() - consult/2 is
file:consult(root_dir_relative_path(File)), and do_write_release/3 the
same - so the working directory was only ever visible to this function's
own File.exists? guard, which is what let the file it looked for and the
file OTP writes be different ones. It stays create_RELEASES/3, never /4
with the root supplied: the three-argument form passes an empty Root, and
that is what makes check_rel_data/4 store library directories as
lib/<app>-<vsn>, "to make it easy to create a relocatable RELEASES file".
A root would bake this machine's paths into a file whose point is that it
can be moved, where no assertion on the end state would find them.

upgradable/0 is where bin/castle's shell test for releases/RELEASES
belongs, because the file is not the question. release_handler reads it
once, in init/1, and otherwise synthesises a record from the boot
script's name and version with the libs field left empty; nothing later
replaces that record, and an upgrade from it reports success while
leaving any application whose version changed, but whose code the relup
does not load, running from the library directory of the release being
replaced. The empty application list is the exact discriminator -
which_releases/0 reports mk_lib_name(Libs), mk_lib_name([]) is [], and a
record read from a file names at least kernel and stdlib - and asking the
node is what sees the case the shell cannot: a file that appeared after
the boot that went looking for it.

BREAKING CHANGE: Castle.generate/1 is removed, along with the
build.config path through install/1 and commit/1.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
AGENTS.md described the two-shape dispatch as a load-bearing property
and recorded the umask gap in write_sys_config as a known one; both
belonged to the path that has gone, and a note about a gap in deleted
code reads as a gap in the code that is left. The boot-time race on
sys.config went with it too - nothing Castle does runs at boot any more -
and the cold-boot interaction it listed as unreachable is reachable now
that forecastle#6 has landed, so what is missing there is a test rather
than a dependency.

RELEASE.md is notes for an unreleased 1.0.0, so generate/1 is not a
function this release changes but one it removes: 0.3.1 published it.
The gate and the make_releases change are operator-visible in their own
right - one refuses an upgrade that used to be accepted, the other stops
a caller having to change directory first.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
@ausimian

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 2346b96f82

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ausimian
ausimian merged commit 5c5e9e0 into release/1.0.0 Aug 22, 2026
7 checks passed
@ausimian
ausimian deleted the issue/13-step3 branch August 22, 2026 11:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant